﻿// NFakeMail - A fake SMTP Server for software testing.
// 
// Copyright (C) 2010, João Angelo
// 
// This file is part of NFakeMail.
//
// NFakeMail is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
// 
// NFakeMail is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
// 
// You should have received a copy of the GNU General Public License
// along with NFakeMail; if not, see <http://www.gnu.org/licenses/>.

namespace NFakeMail
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Globalization;
    using System.IO;
    using System.Linq;
    using System.Net;
    using System.Reflection;
    using System.Threading;
    using IronPython.Hosting;
    using IronPython.Runtime.Exceptions;
    using Microsoft.Scripting.Hosting;

    /// <summary>
    /// Represents the fake SMTP server.
    /// </summary>
    /// <author> jmangelo </author>
    /// <history>
    /// ______________________________________________________________________________________
    /// History :
    /// 20100816 [+] jmangelo     Initial version
    /// ______________________________________________________________________________________
    /// </history>
    public sealed class FakeSmtpServer : IDisposable
    {
        private static readonly OutputSink DefaultOutputSink;

        private object receivedMessagesSyncRoot = new object();

        static FakeSmtpServer()
        {
            DefaultOutputSink = OutputSink.Chain(
                new ConsoleOutputSink(),
                new FileSystemOutputSink(Environment.CurrentDirectory));
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="FakeSmtpServer"/> class.
        /// </summary>
        /// <param name="port">The port where to listen for requests.</param>
        public FakeSmtpServer(int port)
            : this(DefaultOutputSink, port) { }

        /// <summary>
        /// Initializes a new instance of the <see cref="FakeSmtpServer"/> class.
        /// </summary>
        /// <param name="output">The output sink used to provide feedback for the received messages.</param>
        /// <param name="port">The port where to listen for requests.</param>
        public FakeSmtpServer(OutputSink output, int port)
            : this(output, "localhost", port) { }

        private FakeSmtpServer(OutputSink output, string host, int port)
        {
            if (output == null)
            {
                throw new ArgumentNullException("output");
            }

            if (port < IPEndPoint.MinPort)
            {
                throw new ArgumentOutOfRangeException(
                    "port", 
                    "The port parameter must be greater than IPEndPoint.MinPort.");
            }

            if (port > IPEndPoint.MaxPort)
            {
                throw new ArgumentOutOfRangeException(
                    "port",
                    "The port parameter must be less than IPEndPoint.MaxPort.");
            }

            this.Host = host;
            this.Port = port;

            this.Out = output;

            this.ReceivedMessages = new LinkedList<FakeMailMessage>();
            this.ReceivedMessageEvent = new AutoResetEvent(false);

            this.StartPythonServer();
        }

        /// <summary>
        /// Gets the port where this <see cref="FakeSmtpServer"/> is listening for requests.
        /// </summary>
        /// <value>The port.</value>
        public int Port { get; private set; }

        /// <summary>
        /// Gets or sets a value indicating whether this <see cref="FakeSmtpServer"/> is disposed.
        /// </summary>
        /// <value><c>true</c> if disposed; otherwise, <c>false</c>.</value>
        public bool Disposed { get; private set; }

        private string Host { get; set; }

        private OutputSink Out { get; set; }

        private ScriptScope HostedPythonServer { get; set; }

        private FakeMailMessageReceiver MessageReceiver { get; set; }

        private AutoResetEvent ReceivedMessageEvent { get; set; }

        private LinkedList<FakeMailMessage> ReceivedMessages { get; set; }

        /// <summary>
        /// Waits until a message is available for retrieval.
        /// </summary>
        /// <returns>A received mail message.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        public FakeMailMessage WaitForMessage()
        {
            return this.WaitForMessage(null, Int32.MaxValue);
        }

        /// <summary>
        /// Waits until a message is available for retrieval.
        /// </summary>
        /// <param name="subject">The exact subject of the message to wait for.</param>
        /// <returns>A received mail message.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        public FakeMailMessage WaitForMessage(string subject)
        {
            return this.WaitForMessage(subject, Int32.MaxValue);
        }

        /// <summary>
        /// Waits until a message is available for retrieval.
        /// </summary>
        /// <param name="timeout">The timeout in milliseconds to wait for an available message.</param>
        /// <returns>A received mail message.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The timeout parameter is a negative integer.
        /// </exception>
        /// <exception cref="TimeoutException">
        /// A message was not received within the specified timeout.
        /// </exception>
        public FakeMailMessage WaitForMessage(int timeout)
        {
            return this.WaitForMessage(null, timeout);
        }

        /// <summary>
        /// Waits until a message is available for retrieval.
        /// </summary>
        /// <param name="subject">The exact subject of the message to wait for.</param>
        /// <param name="timeout">The timeout in milliseconds to wait for an available message.</param>
        /// <returns>A received mail message.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The timeout parameter is a negative integer.
        /// </exception>
        /// <exception cref="TimeoutException">
        /// A message was not received within the specified timeout.
        /// </exception>
        public FakeMailMessage WaitForMessage(string subject, int timeout)
        {
            if (this.Disposed)
            {
                throw new ObjectDisposedException(typeof(FakeSmtpServer).Name);
            }

            if (timeout < 0)
            {
                throw new ArgumentOutOfRangeException(
                    "timeout",
                    "The timeout parameter is a negative integer.");
            }

            bool newMessageReceived;

            int remainingTimeout = timeout;

            var stopwatch = new Stopwatch();

            do
            {
                lock (this.receivedMessagesSyncRoot)
                {
                    var message = this.ReceivedMessages.FirstOrDefault(
                        m => subject == null ||
                            m.Subject.Equals(subject, StringComparison.OrdinalIgnoreCase));

                    if (message != null)
                    {
                        this.ReceivedMessages.Remove(message);

                        return message;
                    }
                }

                stopwatch.Restart();
                newMessageReceived = this.ReceivedMessageEvent.WaitOne(remainingTimeout);
                stopwatch.Stop();

                remainingTimeout = Math.Max(0, remainingTimeout - (int)stopwatch.ElapsedMilliseconds);
            } 
            while (newMessageReceived);

            throw new TimeoutException("A message was not received within the specified timeout.");
        }

        /// <summary>
        /// Waits until the specified number of messages are available for retrieval.
        /// </summary>
        /// <param name="count">The number of messages to retrieve.</param>
        /// <returns>An array of mail messages.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The count parameter is less than one.
        /// </exception>
        public FakeMailMessage[] WaitForMessages(int count)
        {
            return this.WaitForMessages(count, null, Int32.MaxValue);
        }

        /// <summary>
        /// Waits until the specified number of messages are available for retrieval.
        /// </summary>
        /// <param name="count">The number of messages to retrieve.</param>
        /// <param name="subject">The exact subject of the messages to wait for.</param>
        /// <returns>An array of mail messages.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The count parameter is less than one.
        /// </exception>
        public FakeMailMessage[] WaitForMessages(int count, string subject)
        {
            return this.WaitForMessages(count, subject, Int32.MaxValue);
        }

        /// <summary>
        /// Waits until the specified number of messages are available for retrieval.
        /// </summary>
        /// <param name="count">The number of messages to retrieve.</param>
        /// <param name="timeout">The timeout in milliseconds to wait for the messages.</param>
        /// <returns>An array of mail messages.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The count parameter is less than one.
        /// <br />-or-<br />
        /// The timeout parameter is a negative integer.
        /// </exception>
        /// <exception cref="TimeoutException">
        /// A message was not received within the specified timeout.
        /// </exception>
        public FakeMailMessage[] WaitForMessages(int count, int timeout)
        {
            return this.WaitForMessages(count, null, timeout);
        }

        /// <summary>
        /// Waits until the specified number of messages are available for retrieval.
        /// </summary>
        /// <param name="count">The number of messages to retrieve.</param>
        /// <param name="subject">The exact subject of the messages to wait for.</param>
        /// <param name="timeout">The timeout in milliseconds to wait for the messages.</param>
        /// <returns>An array of mail messages.</returns>
        /// <exception cref="ObjectDisposedException">
        /// The <see cref="FakeSmtpServer"/> instance was already disposed.
        /// </exception>
        /// <exception cref="ArgumentOutOfRangeException">
        /// The count parameter is less than one.
        /// <br />-or-<br />
        /// The timeout parameter is a negative integer.
        /// </exception>
        /// <exception cref="TimeoutException">
        /// A message was not received within the specified timeout.
        /// </exception>
        public FakeMailMessage[] WaitForMessages(int count, string subject, int timeout)
        {
            if (this.Disposed)
            {
                throw new ObjectDisposedException(typeof(FakeSmtpServer).Name);
            }

            if (count < 1)
            {
                throw new ArgumentOutOfRangeException(
                    "count",
                    "The count value must be a positive integer.");
            }

            if (timeout < 0)
            {
                throw new ArgumentOutOfRangeException(
                    "timeout",
                    "The timeout value must be zero or a positive integer.");
            }

            var messages = new List<FakeMailMessage>(count);

            int remainingTimeout = timeout;

            var stopwatch = new Stopwatch();

            for (int i = 0; i < count; i++)
            {
                stopwatch.Restart();
                try
                {
                    messages.Add(this.WaitForMessage(subject, remainingTimeout));
                }
                catch (TimeoutException)
                {
                    lock (this.receivedMessagesSyncRoot)
                    {
                        messages.ForEach(m => this.ReceivedMessages.AddFirst(m));
                    }

                    throw;
                }
                stopwatch.Stop();

                remainingTimeout = Math.Max(0, remainingTimeout - (int)stopwatch.ElapsedMilliseconds);
            }

            return messages.ToArray();
        }

        /// <summary>
        /// Clears the repository of received messages.
        /// </summary>
        public void ClearAvailableMessages()
        {
            if (this.Disposed)
            {
                throw new ObjectDisposedException(typeof(FakeSmtpServer).Name);
            }

            lock (this.receivedMessagesSyncRoot)
            {
                this.ReceivedMessages.Clear();
            }
        }

        /// <summary>
        /// Gets all the currently available messages in the message repository.
        /// </summary>
        /// <returns>An array of mail messages.</returns>
        public FakeMailMessage[] GetAvailableMessages()
        {
            if (this.Disposed)
            {
                throw new ObjectDisposedException(typeof(FakeSmtpServer).Name);
            }

            lock (this.receivedMessagesSyncRoot)
            {
                var messages = this.ReceivedMessages.ToArray();

                this.ClearAvailableMessages();

                return messages;
            }
        }

        /// <summary>
        /// Releases all resources associated to this <see cref="FakeSmtpServer"/> instance.
        /// </summary>
        public void Dispose()
        {
            this.Disposed = true;

            this.StopPythonServer();
        }

        private static string CreatePythonScriptFile()
        {
            string tempFileName = String.Concat(Path.GetTempFileName(), ".py");

            var executingAssembly = Assembly.GetExecutingAssembly();

            var data = executingAssembly.GetManifestResourceStream(typeof(FakeSmtpServer), "FakeMail.py");

            string script = new StreamReader(data).ReadToEnd();

            File.WriteAllText(tempFileName, script);

            return tempFileName;
        }

        private void StartPythonServer()
        {
            string ironPythonPath = Environment.GetEnvironmentVariable("IRONPYTHONPATH");

            if (String.IsNullOrWhiteSpace(ironPythonPath))
            {
                throw new InvalidOperationException(
                    "The IRONPYTHONPATH environment variable is not defined.");
            }

            string msg = String.Format(
                CultureInfo.InvariantCulture,
                "Starting server at port {0}...",
                this.Port);

            this.Out.Write(msg);

            this.MessageReceiver = new FakeMailMessageReceiver();
            this.MessageReceiver.MessageReceived += this.Receiver_MessageReceived;

            var engine = Python.CreateEngine();

            var searchPaths = engine.GetSearchPaths();

            foreach (var path in ironPythonPath.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
            {
                searchPaths.Add(path);
            }

            engine.SetSearchPaths(searchPaths);

            string fakeMailScriptFileName = CreatePythonScriptFile();

            this.HostedPythonServer = engine.Runtime.UseFile(fakeMailScriptFileName);

            this.HostedPythonServer.SetVariable("receiver", this.MessageReceiver);

            dynamic server = (dynamic)this.HostedPythonServer;

            var thread = new Thread(() => server.Start(this.Host, this.Port))
            {
                IsBackground = true
            };

            thread.Start();

            this.WaitForServerToStart();
        }

        private void WaitForServerToStart()
        {
            dynamic server;
            do
            {
                this.HostedPythonServer.TryGetVariable("server", out server);

                Thread.Sleep(100);
            } 
            while (server == null);
        }

        private void StopPythonServer()
        {
            string msg = String.Format(
                CultureInfo.InvariantCulture, 
                "Stopping server at port {0}...", 
                this.Port);

            this.Out.Write(msg);

            this.MessageReceiver.MessageReceived -= this.Receiver_MessageReceived;

            ((dynamic)this.HostedPythonServer).Stop();
        }

        private void Receiver_MessageReceived(object sender, FakeMailMessageReceivedEventArgs e)
        {
            this.Out.Write(e.Message);

            lock (this.receivedMessagesSyncRoot)
            {
                this.ReceivedMessages.AddLast(e.Message);
            }

            this.ReceivedMessageEvent.Set();
        }
    }
}
